--- id: TASK-020 title: Spellcheck with undercurl underlines status: "\U0001F3C1 Done" assignee: [] created_date: '2026-06-29 18:11' updated_date: '2026-06-30 01:45' labels: - feature - release-1 dependencies: [] priority: medium ordinal: 20000 --- ## Description Lightweight inline spellcheck: red wavy (undercurl) underline under misspelled words, with one-key suggest-and-replace. Catch obvious typos only — not an intense/morphological checker. Must stay zero-cgo so the Homebrew/go-install build keeps working, and must not bloat the binary much. RENDERING (undercurl) - Misspelled-word spans emit raw SGR: undercurl '\e[4:3m' + underline color '\e[58:2::R:G:Bm' (theme-driven red, e.g. Flexoki red 217,54,42), reset '\e[59;4:0m'. - lipgloss/termenv DON'T expose undercurl or underline-color — add a raw-escape span attribute in the editor scanner. Preserve the markup-visible invariant: span text still concatenates to the raw line; only the SGR wrapper changes. - Degrade gracefully: terminals without 4:3 show straight underline or ignore (Ghostty/kitty/WezTerm/foot/VTE support it). tmux needs terminal-features passthrough — document it. DICTIONARY (lightweight, pure-Go) - Embed a single common-English wordlist (SCOWL-derived, ~50-60k most-common words; gzip-embedded via embed.FS, decompressed into a hashset at startup). Target small added binary weight, not full coverage. - No cgo, no hunspell — keeps 'go build .' / brew formula clean. - Case-insensitive membership; treat possessives/simple plurals leniently if cheap. SUGGEST & REPLACE - Build a BK-tree from the same embedded dict at startup (edit-distance <=2 lookup; modest RAM, no extra binary weight; only queried on user trigger, never per render). - Trigger key (suggest Ctrl+; TBD) when the cursor is on/adjacent to a flagged word opens a small popup of the top ~5 suggestions ranked by edit distance (tie-break by word frequency/length). - Pick with arrows+Enter or a number key -> replace the misspelled word span in the buffer, mark dirty, clear its underline. Esc dismisses. - The same popup includes an 'Add to dictionary' entry (writes the word to dict.txt) so add-word and replace share one UI. WHAT TO SKIP (no false positives) - Code fences and inline code, URLs, wikilinks [[...]], markdown link targets, YAML frontmatter values, and (when on in a code file) anything non-prose. PERSONAL DICTIONARY (easy add) - Plain file ~/.config/glint/dict.txt, one word per line, user-editable by hand. - 'Add to dictionary' from the suggestion popup (or a direct add-word key) appends to dict.txt and clears the underline live. TOGGLE / DEFAULTS - On/off toggle (key TBD) for the session. - Default ON for .md/.markdown/.txt and unnamed buffers; default OFF for recognized code-file extensions (driven by the same extension map as TASK-018 syntax highlighting — share it). - Config key to override the default (e.g. spellcheck = auto|on|off). PERF - Check only visible-viewport words per render; cache word->ok results so typing doesn't re-check the whole doc each keystroke; invalidate a word's cache entry when added to the personal dict. Suggestion lookups run only on trigger. ## Acceptance Criteria - [x] #1 Misspelled prose words show a red undercurl underline in Ghostty; unsupported terminals degrade to plain/no underline - [x] #2 Dictionary is pure-Go embedded (no cgo); binary size increase is modest and 'go build .' + brew formula still work - [x] #3 Code fences, inline code, URLs, wikilinks, link targets, and frontmatter values are never flagged - [x] #4 Adding the word under the cursor appends to ~/.config/glint/dict.txt and removes the underline live; hand-editing dict.txt also works - [x] #5 Spellcheck defaults ON for md/txt/unnamed buffers and OFF for code files, with a config override and a session toggle - [x] #6 Triggering on a flagged word shows ~5 ranked suggestions; picking one replaces the word in place, marks the buffer dirty, and clears the underline - [x] #7 Clicking a flagged (underlined) word with the mouse opens the same suggestion popup; clicking elsewhere just moves the cursor ## Implementation Plan Decisions: dict=common ~60k (Norvig-freq ∩ curated words_alpha, frequency-ranked, rejects common typos); key=single Alt+; popup hub (suggest/toggle/add/ignore); mouse click on flagged word also opens popup. Slices (each TDD): A. internal/spell: embed words.txt.gz (60k, freq order) → Known(word) case-insensitive + possessive/plural leniency. B. internal/spell: BK-tree from dict → Suggest(word,max) ranked by edit-distance then frequency rank. C. internal/spell: personal dict ~/.config/glint/dict.txt load+Add (append+in-memory), Known unions it. D. editor Span.Wavy + theme.Spell red color; renderSpans/renderSpansCursor emit undercurl SGR (4:3 + 58:2 color), preserve markup-visible invariant; degrade gracefully. E. editor spellcheck pass: mark misspelled prose words Wavy; skip code fences/inline code/URLs/wikilinks/link targets/frontmatter values/headings markup + whole-doc when codeFile!=''; viewport-only + word→ok cache invalidated on add; session toggle. F. app popup mode (Alt+; + mouse click on flagged word): 5 suggestions + Toggle + Add + Ignore; arrows/number+Enter apply→replace word, dirty, clear underline; Esc dismiss. G. config spellcheck=auto|on|off; default ON md/txt/unnamed, OFF code (share TASK-018 ext map). ## Implementation Notes Slice A/B/C done (commit 2c0a77e9): internal/spell — 60k embedded dict (freq∩curated, rejects common typos), Known() w/ possessive leniency, BK-tree Suggest() OSA-reranked + freq tie-break, personal dict load/Add at ~/.config/glint/dict.txt. 11 tests green. Slice D/E done (commits 92f9461e, +theme.Spell): undercurl Span rendering (raw SGR 4:3 + 58:2 color, invariant-preserving, graceful degrade), scanner Prose tagging, spellPass with skip rules (code/inline-code/URL/email/wikilink/link-target/frontmatter/acronym/camelcase/<3-char), word->ok cache, codeFile + toggle gates, AddToDictionary clears cache live. 13 editor tests green. Slice F/G done (commits 1221e42d, 1d757ec1): config spellcheck=auto|on|off + DictPath, dict loaded at app startup (embedded + personal), Alt+; + mouse-click-on-flagged-word open the popup (suggestions 1-9 / a Add / i Ignore / t Toggle, Esc), toggle-only popup when nothing flagged, glint -c/-h/help-overlay/README/CHANGELOG documented incl. tmux undercurl passthrough. Smoke test confirms App.View emits 4:3+58:2 SGR. All 7 ACs met. Full suite + vet + build green; binary +~250KB (embedded gz).